Simulating the College Football Playoff with a Bradley–Terry Model

Executive Summary

In college football’s newly expanded playoff system, understanding a team’s championship probability matters just as much as understanding a team’s seeding. For our project, we aimed to find out if the College Football Playoff started today, and which teams would actually have the best shot at coming out on top. The new 12-team format introduced many complexities that the traditional rankings don’t capture. The top four teams get crucial first-round byes, another four host home games, and the remaining four face elimination immediately. We wanted to minimize the noise of team rankings to provide quantitative championship odds based on team strength data, giving stakeholders and fans across college football a picture of who should be planning championship parades and who is gonna need some extra luck. We used data from the CFB Fast R-package, utilizing four major analytical systems that evaluate team quality: ESPN’s Football Power Index, SP+ efficiency metric, Elo’s dynamic ratings, and the Simple Rating System. Rather than picking one system to focus on, we transformed each metric into standardized scores to make a composite measure, ensuring balanced input from predictive models, momentum-based rankings, and margin evaluations. This gave us a single strength rating for each of the 12 playoff teams drawn from the November 4th CFB rankings. From there, we implemented a Bradley-Terry model to calculate win probabilities for each matchup, ran 500 complete tournament simulations from first-round kickoffs through the championship game in an attempt to map out the probability landscape.

What we discovered was a pretty chalk playoff picture, yet stil filled with more variability than you’d expect. Ohio State sits atop the probability leaderboard, claiming the championship in roughly 27% of simulations, but still not guaranteed. Indiana trails closely with 24% title odds, while Alabama and Texas A&M each hover around 11%. These four bye recipients combine to win nearly three-quarters of all championships in our model. The next tier teams, like Oregon, Georgia, and Notre Dame, each win between 6-7% of the time, holding real but slim chances. Beyond that, lower seeds face rough odds: Memphis, Virginia, and BYU combined win fewer than one championship per hundred tournaments. We also found that hosting a first-round playoff game provides a tangible edge, with home teams advancing 55-60% of the time, though this falls well short of guaranteeing victory. One interesting thing we noted was that even favored Ohio State loses the championship in nearly three of every four tournament runs, demonstrating how playoff randomness can derail even the best programs.

The takeaways are significant for anyone invested in college football success. Securing a top-four seed and bye doesn’t just mean an easier path; it transforms championship probability, almost tripling your odds. For programs on the bubble, the difference between seed 4 and seed 5 represents a massive difference in championship equity. While our analysis has room to grow. Future iterations could incorporate team-specific pressure effects and even injuries. Which could lead to an even more accurate way to predict the 2025 CFB National Champions.

Introduction

We use a simple Bradley–Terry (BT) model to convert composite team ratings into win probabilities and simulate the 12-team CFP bracket. Ratings come from FPI, SP+, Elo, and SRS (z-scored and averaged). The BT probability for team i vs j is \(p_{i,j} = \sigma(\beta_i - \beta_j), \quad \sigma(x) = \frac{1}{1+e^{-x}}\) We show results for 2025 and a 2024 back-test for comparison.

Sys.setenv(CFBD_API_KEY = "ITEs7pbYz8z66o4ksvNMW2m31B2ZPCnC+dbIXj2tT1mjMmJx02Dd9MM0fZpNhAyO")

library(cfbfastR)
library(ggplot2)
library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union

Data Cleaning

We start by adding in the 12 teams that would be in the College Football Playoffs if they started today and then pulling metrics for each team from the cfbfastR package. We used the metrics FPI, SP+, Elo, and SRS because they are strong predictors of team strength. We z-scored and averaged these to make a composite score which is then what we used as our lambdas for the Bradley-Terry model.

teams = c("Ohio State", "Indiana", "Texas A&M", "Alabama", "Georgia", "Ole Miss", "BYU", "Texas Tech", "Oregon", "Notre Dame", "Virginia", "Memphis")


fpi <- cfbd_ratings_fpi(year = 2025) |> dplyr::transmute(team, fpi)
sp  <- cfbd_ratings_sp(year = 2025)  |> dplyr::transmute(team, sp = rating) |> dplyr::filter(team != "nationalAverages")
elo <- cfbd_ratings_elo(year = 2025) |> dplyr::transmute(team, elo)
srs <- cfbd_ratings_srs(year = 2025) |> dplyr::transmute(team, srs = rating)

z_scores <- fpi |> dplyr::full_join(sp,  by = "team") |> dplyr::full_join(elo, by = "team") |> dplyr::full_join(srs, by = "team") |> tidyr::drop_na() |>
  dplyr::mutate(
    z_fpi = as.numeric(scale(fpi)),
    z_sp  = as.numeric(scale(sp)),
    z_elo = as.numeric(scale(elo)),
    z_srs = as.numeric(scale(srs)),
    composite = (z_fpi + z_sp + z_elo + z_srs)/4
  )

ratings_com = c()
for (t in teams) {
  rating = (z_scores |> dplyr::filter(team == t))$composite[1]
  cat(t, ": ", rating, "\n", sep="")
  ratings_com = c(ratings_com, rating)
}
## Ohio State: 2.325806
## Indiana: 2.324875
## Texas A&M: 1.607077
## Alabama: 1.657976
## Georgia: 1.621414
## Ole Miss: 1.487946
## BYU: 1.280167
## Texas Tech: 1.700043
## Oregon: 1.968132
## Notre Dame: 1.935658
## Virginia: 0.5962518
## Memphis: 0.7123528
probs_com <- 1/(1 + exp(-1 * outer(X = ratings_com, Y = ratings_com, FUN = "-")))
diag(probs_com) <- NA
round(probs_com, digits = 3)
##        [,1]  [,2]  [,3]  [,4]  [,5]  [,6]  [,7]  [,8]  [,9] [,10] [,11] [,12]
##  [1,]    NA 0.500 0.672 0.661 0.669 0.698 0.740 0.652 0.588 0.596 0.849 0.834
##  [2,] 0.500    NA 0.672 0.661 0.669 0.698 0.740 0.651 0.588 0.596 0.849 0.834
##  [3,] 0.328 0.328    NA 0.487 0.496 0.530 0.581 0.477 0.411 0.419 0.733 0.710
##  [4,] 0.339 0.339 0.513    NA 0.509 0.542 0.593 0.489 0.423 0.431 0.743 0.720
##  [5,] 0.331 0.331 0.504 0.491    NA 0.533 0.584 0.480 0.414 0.422 0.736 0.713
##  [6,] 0.302 0.302 0.470 0.458 0.467    NA 0.552 0.447 0.382 0.390 0.709 0.685
##  [7,] 0.260 0.260 0.419 0.407 0.416 0.448    NA 0.397 0.334 0.342 0.665 0.638
##  [8,] 0.348 0.349 0.523 0.511 0.520 0.553 0.603    NA 0.433 0.441 0.751 0.729
##  [9,] 0.412 0.412 0.589 0.577 0.586 0.618 0.666 0.567    NA 0.508 0.798 0.778
## [10,] 0.404 0.404 0.581 0.569 0.578 0.610 0.658 0.559 0.492    NA 0.792 0.773
## [11,] 0.151 0.151 0.267 0.257 0.264 0.291 0.335 0.249 0.202 0.208    NA 0.471
## [12,] 0.166 0.166 0.290 0.280 0.287 0.315 0.362 0.271 0.222 0.227 0.529    NA
z_scores
## # A tibble: 137 × 10
##    team              fpi    sp   elo   srs  z_fpi   z_sp  z_elo  z_srs composite
##    <chr>           <dbl> <dbl> <dbl> <dbl>  <dbl>  <dbl>  <dbl>  <dbl>     <dbl>
##  1 Notre Dame      21.1   21.4  2153  22.4  1.77   1.60   2.49   1.88      1.94 
##  2 Oklahoma State -14.4  -17.5  1049 -14.1 -1.19  -1.41  -1.72  -1.16     -1.37 
##  3 Kansas State     8.18   8    1705   7.3  0.691  0.564  0.783  0.623     0.665
##  4 Baylor           6.26   5.5  1621   3    0.530  0.370  0.462  0.264     0.407
##  5 Iowa State       8.00   9    1589   9.1  0.676  0.642  0.340  0.773     0.608
##  6 Texas Tech      18.3   24.3  1903  22.6  1.53   1.83   1.54   1.90      1.70 
##  7 Kansas           5.35   5.8  1556   3.7  0.454  0.394  0.215  0.322     0.346
##  8 TCU              9.83  10.5  1700   8.5  0.828  0.758  0.764  0.723     0.768
##  9 West Virginia   -2.53  -6.4  1340  -2.1 -0.203 -0.553 -0.609 -0.161    -0.382
## 10 South Florida    9.11   9.9  1736  13.9  0.768  0.712  0.901  1.17      0.888
## # ℹ 127 more rows

Bracket:

Round 1 hosts are seeds 5–8 vs 12–9. Then 1 vs 8/9 winner, 4 vs 5/12, 2 vs 7/10, 3 vs 6/11, followed by semifinals and championship. We run 500 Monte Carlo simulations, using our probability matrix for each matchup and tally wins by round.

design <- 
  data.frame(home_team = c(5, 6, 7, 8), away_team = c(12, 11, 10, 9)) |>
  dplyr::rowwise() |>
  dplyr::mutate(prob = probs_com[home_team, away_team]) |>
  dplyr::ungroup()

n_sims <- 500
simulated_wins_r1 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r2 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r3 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_champs <- matrix(data = NA, nrow = n_sims, ncol = 12)
for(r in 1:n_sims){
  set.seed(478+r)
  outcomes <- rbinom(n = 4, size = 1, prob = design$prob)
  round1 <-
    design |>
    dplyr::select(home_team, away_team) |>
    dplyr::mutate(
      outcome = outcomes,
      winner = ifelse(outcome == 1, home_team, away_team),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r1[r,] = round1 |> dplyr::pull(Wins)
  
  winner_512 = ifelse(round1$Wins[5] == 1, 5, 12)
  winner_611 = ifelse(round1$Wins[6] == 1, 6, 11)
  winner_710 = ifelse(round1$Wins[7] == 1, 7, 10)
  winner_89 = ifelse(round1$Wins[8] == 1, 8, 9)
  
  design2 <- 
    data.frame(high_seed = c(1, 2, 3, 4), low_seed = c(winner_89, winner_710, winner_611, winner_512)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[high_seed, low_seed]) |>
    dplyr::ungroup()
  design2
  
  outcomes2 <- rbinom(n = 4, size = 1, prob = design2$prob)
  round2 <-
    design2 |>
    dplyr::select(high_seed, low_seed) |>
    dplyr::mutate(
      outcome = outcomes2,
      winner = ifelse(outcome == 1, high_seed, low_seed),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r2[r,] = round2 |> dplyr::pull(Wins)
  
  winner_4512 = if (round2$Wins[4] == 1) {
    4
  } else if (round2$Wins[5] == 1) {
    5
  } else {
    12
  }
  winner_3611 = if (round2$Wins[3] == 1) {
    3
  } else if (round2$Wins[6] == 1) {
    6
  } else {
    11
  }
  winner_2710 = if (round2$Wins[2] == 1) {
    2
  } else if (round2$Wins[7] == 1) {
    7
  } else {
    10
  }
  winner_189 = if (round2$Wins[1] == 1) {
    1
  } else if (round2$Wins[8] == 1) {
    8
  } else {
    9
  }
  
  design3 <- 
    data.frame(team1 = c(winner_189, winner_2710), team2 = c(winner_4512, winner_3611)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[team1, team2]) |>
    dplyr::ungroup()
  design3
  
  outcomes3 <- rbinom(n = 2, size = 1, prob = design3$prob)
  round3 <-
    design3 |>
    dplyr::select(team1, team2) |>
    dplyr::mutate(
      outcome = outcomes3,
      winner = ifelse(outcome == 1, team1, team2),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r3[r,] = round3 |> dplyr::pull(Wins)
  
  champ_team1 = (round3 |> dplyr::filter(Wins != 0))$Team[1]
  champ_team2 = (round3 |> dplyr::filter(Wins != 0))$Team[2]
  
  design4 <- 
    data.frame(team1 = c(champ_team1), team2 = c(champ_team2)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[team1, team2]) |>
    dplyr::ungroup()
  design4
  
  outcomes4 <- rbinom(n = 1, size = 1, prob = design4$prob)
  round4 <-
    design4 |>
    dplyr::select(team1, team2) |>
    dplyr::mutate(
      outcome = outcomes4,
      winner = ifelse(outcome == 1, team1, team2),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_champs[r,] = round4 |> dplyr::pull(Wins)
}

Results

We first look at what percentage chance each team has to move onto the next round.

percent_results <- data.frame(matrix(nrow = 4, ncol = 0))

for (i in 1:length(teams)) {
  team = teams[i]
  wins1 = table(simulated_wins_r1[,i])[2]
  percent1 = ifelse(i < 5, 100, round(wins1/n_sims, digits = 4)*100)
  wins2 = table(simulated_wins_r2[,i])[2]
  percent2 = round(wins2/n_sims, digits = 4)*100
  wins3 = table(simulated_wins_r3[,i])[2]
  percent3 = round(wins3/n_sims, digits = 4)*100
  wins4 = table(simulated_champs[,i])[2]
  percent4 = round(wins4/n_sims, digits = 4)*100
  
  percent_results[[team]] <- c(percent1, percent2, percent3, percent4)
}

rownames(percent_results) <- c("Round 1", "Quarterfinal", "Semifinal", "Championship")

print(percent_results)
##              Ohio State Indiana Texas A&M Alabama Georgia Ole Miss  BYU
## Round 1           100.0   100.0     100.0   100.0    72.8     71.4 36.6
## Quarterfinal       62.8    64.8      57.6    55.4    35.8     34.4  9.4
## Semifinal          43.2    44.2      22.2    22.2    12.8     12.2  3.6
## Championship       26.8    24.2      10.8    11.2     6.8      2.0  1.0
##              Texas Tech Oregon Notre Dame Virginia Memphis
## Round 1            43.2   56.8       63.4     28.6    27.2
## Quarterfinal       13.0   24.2       25.8      8.0     8.8
## Semifinal           6.6   13.4       17.0      0.8     1.8
## Championship        3.6    7.4        5.6      0.4     0.2

We then create plots for each round showing the percent chance of advancing.

team_colors <- c(
  "Alabama"    = "#9e1b32",
  "Indiana"    = "#990000",
  "Ohio State" = "#BB0000",
  "Texas A&M"  = "#500000",
  "Georgia"    = "#BA0C2F",
  "Ole Miss"   = "#006BA6",
  "Notre Dame" = "#c99700",
  "Oregon"     = "#007030",
  "Texas Tech" = "#CC0000",
  "BYU"        = "#0062B8",
  "Virginia"   = "#F84C1E",
  "Memphis"    = "#003087"
)

plot_df <- percent_results |>
  as.data.frame() |>
  tibble::rownames_to_column("Round") |>
  tidyr::pivot_longer(-Round, names_to = "Team", values_to = "Percent")

plot_r1 <- plot_df |> dplyr::filter(Round == "Round 1")
plot_r2 <- plot_df |> dplyr::filter(Round == "Quarterfinal")
plot_r3 <- plot_df |> dplyr::filter(Round == "Semifinal")
plot_r4 <- plot_df |> dplyr::filter(Round == "Championship")

round1plot = ggplot2::ggplot(plot_r1, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Round 1 Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round2plot = ggplot2::ggplot(plot_r2, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Quarterfinal Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round3plot = ggplot2::ggplot(plot_r3, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Semifinal Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round4plot = ggplot2::ggplot(plot_r4, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Championship Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

ggsave("round1_plot.png", plot = round1plot, width = 8, height = 5, dpi = 300)
ggsave("round2_plot.png", plot = round2plot, width = 8, height = 5, dpi = 300)
ggsave("round3_plot.png", plot = round3plot, width = 8, height = 5, dpi = 300)
ggsave("round4_plot.png", plot = round4plot, width = 8, height = 5, dpi = 300)

round1plot

round2plot

round3plot

round4plot

The results are about as expected. Ohio State, Indiana, Alabama, and Texas A&M all have 100% chance to advance in the first round because they all have byes. We see that Ohio State and Indiana are clearly favored far above everyone else, with Indiana having a better chance to advance to the championship, but Ohio State has the slight edge on them to win that game. An interesting note is that Oregon (#9 seed) has the 5th best odds to win the national championship. Below we see what the bracket would look like based off of our model’s predictions.

Figure 1
Figure 1

2024 Back-Test

We run the same exact code on the 2024 college football playoffs so we can compare how our model performed.

teams = c("Oregon", "Georgia", "Boise State", "Arizona State", "Texas", "Penn State", "Notre Dame", "Ohio State", "Tennessee", "Indiana", "SMU", "Clemson")


fpi <- cfbd_ratings_fpi(year = 2024) |> dplyr::transmute(team, fpi)
sp  <- cfbd_ratings_sp(year = 2024)  |> dplyr::transmute(team, sp = rating) |> dplyr::filter(team != "nationalAverages")
elo <- cfbd_ratings_elo(year = 2024, season_type = "regular") |> dplyr::transmute(team, elo)
srs <- cfbd_ratings_srs(year = 2024) |> dplyr::transmute(team, srs = rating)

z_scores <- fpi |> dplyr::full_join(sp,  by = "team") |> dplyr::full_join(elo, by = "team") |> dplyr::full_join(srs, by = "team") |> tidyr::drop_na() |>
  mutate(
    z_fpi = as.numeric(scale(fpi)),
    z_sp  = as.numeric(scale(sp)),
    z_elo = as.numeric(scale(elo)),
    z_srs = as.numeric(scale(srs)),
    composite = (z_fpi + z_sp + z_elo + z_srs)/4
  )

ratings_com = c()
for (t in teams) {
  rating = (z_scores |> dplyr::filter(team == t))$composite[1]
  cat(t, ": ", rating, "\n", sep="")
  ratings_com = c(ratings_com, rating)
}
## Oregon: 1.865358
## Georgia: 1.783524
## Boise State: 0.8969355
## Arizona State: 0.998138
## Texas: 1.970282
## Penn State: 1.79114
## Notre Dame: 2.102223
## Ohio State: 2.235929
## Tennessee: 1.614369
## Indiana: 1.600961
## SMU: 1.393184
## Clemson: 1.125629
probs_com <- 1/(1 + exp(-1 * outer(X = ratings_com, Y = ratings_com, FUN = "-")))
diag(probs_com) <- NA
round(probs_com, digits = 3)
##        [,1]  [,2]  [,3]  [,4]  [,5]  [,6]  [,7]  [,8]  [,9] [,10] [,11] [,12]
##  [1,]    NA 0.520 0.725 0.704 0.474 0.519 0.441 0.408 0.562 0.566 0.616 0.677
##  [2,] 0.480    NA 0.708 0.687 0.453 0.498 0.421 0.389 0.542 0.546 0.596 0.659
##  [3,] 0.275 0.292    NA 0.475 0.255 0.290 0.231 0.208 0.328 0.331 0.378 0.443
##  [4,] 0.296 0.313 0.525    NA 0.274 0.312 0.249 0.225 0.351 0.354 0.403 0.468
##  [5,] 0.526 0.547 0.745 0.726    NA 0.545 0.467 0.434 0.588 0.591 0.640 0.699
##  [6,] 0.481 0.502 0.710 0.688 0.455    NA 0.423 0.391 0.544 0.547 0.598 0.660
##  [7,] 0.559 0.579 0.769 0.751 0.533 0.577    NA 0.467 0.620 0.623 0.670 0.726
##  [8,] 0.592 0.611 0.792 0.775 0.566 0.609 0.533    NA 0.651 0.654 0.699 0.752
##  [9,] 0.438 0.458 0.672 0.649 0.412 0.456 0.380 0.349    NA 0.503 0.555 0.620
## [10,] 0.434 0.454 0.669 0.646 0.409 0.453 0.377 0.346 0.497    NA 0.552 0.617
## [11,] 0.384 0.404 0.622 0.597 0.360 0.402 0.330 0.301 0.445 0.448    NA 0.566
## [12,] 0.323 0.341 0.557 0.532 0.301 0.340 0.274 0.248 0.380 0.383 0.434    NA
design <- 
  data.frame(home_team = c(5, 6, 7, 8), away_team = c(12, 11, 10, 9)) |>
  dplyr::rowwise() |>
  dplyr::mutate(prob = probs_com[home_team, away_team]) |>
  dplyr::ungroup()

n_sims <- 500
simulated_wins_r1 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r2 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r3 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_champs <- matrix(data = NA, nrow = n_sims, ncol = 12)
for(r in 1:n_sims){
  set.seed(478+r)
  outcomes <- rbinom(n = 4, size = 1, prob = design$prob)
  round1 <-
    design |>
    dplyr::select(home_team, away_team) |>
    dplyr::mutate(
      outcome = outcomes,
      winner = ifelse(outcome == 1, home_team, away_team),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r1[r,] = round1 |> dplyr::pull(Wins)
  
  winner_512 = ifelse(round1$Wins[5] == 1, 5, 12)
  winner_611 = ifelse(round1$Wins[6] == 1, 6, 11)
  winner_710 = ifelse(round1$Wins[7] == 1, 7, 10)
  winner_89 = ifelse(round1$Wins[8] == 1, 8, 9)
  
  design2 <- 
    data.frame(high_seed = c(1, 2, 3, 4), low_seed = c(winner_89, winner_710, winner_611, winner_512)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[high_seed, low_seed]) |>
    dplyr::ungroup()
  design2
  
  outcomes2 <- rbinom(n = 4, size = 1, prob = design2$prob)
  round2 <-
    design2 |>
    dplyr::select(high_seed, low_seed) |>
    dplyr::mutate(
      outcome = outcomes2,
      winner = ifelse(outcome == 1, high_seed, low_seed),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r2[r,] = round2 |> dplyr::pull(Wins)
  
  winner_4512 = if (round2$Wins[4] == 1) {
    4
  } else if (round2$Wins[5] == 1) {
    5
  } else {
    12
  }
  winner_3611 = if (round2$Wins[3] == 1) {
    3
  } else if (round2$Wins[6] == 1) {
    6
  } else {
    11
  }
  winner_2710 = if (round2$Wins[2] == 1) {
    2
  } else if (round2$Wins[7] == 1) {
    7
  } else {
    10
  }
  winner_189 = if (round2$Wins[1] == 1) {
    1
  } else if (round2$Wins[8] == 1) {
    8
  } else {
    9
  }
  
  design3 <- 
    data.frame(team1 = c(winner_189, winner_2710), team2 = c(winner_4512, winner_3611)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[team1, team2]) |>
    dplyr::ungroup()
  design3
  
  outcomes3 <- rbinom(n = 2, size = 1, prob = design3$prob)
  round3 <-
    design3 |>
    dplyr::select(team1, team2) |>
    dplyr::mutate(
      outcome = outcomes3,
      winner = ifelse(outcome == 1, team1, team2),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r3[r,] = round3 |> dplyr::pull(Wins)
  
  champ_team1 = (round3 |> dplyr::filter(Wins != 0))$Team[1]
  champ_team2 = (round3 |> dplyr::filter(Wins != 0))$Team[2]
  
  design4 <- 
    data.frame(team1 = c(champ_team1), team2 = c(champ_team2)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[team1, team2]) |>
    dplyr::ungroup()
  design4
  
  outcomes4 <- rbinom(n = 1, size = 1, prob = design4$prob)
  round4 <-
    design4 |>
    dplyr::select(team1, team2) |>
    dplyr::mutate(
      outcome = outcomes4,
      winner = ifelse(outcome == 1, team1, team2),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_champs[r,] = round4 |> dplyr::pull(Wins)
}

percent_results <- data.frame(matrix(nrow = 4, ncol = 0))

for (i in 1:length(teams)) {
  team = teams[i]
  wins1 = table(simulated_wins_r1[,i])[2]
  percent1 = ifelse(i < 5, 100, round(wins1/n_sims, digits = 4)*100)
  wins2 = table(simulated_wins_r2[,i])[2]
  percent2 = round(wins2/n_sims, digits = 4)*100
  wins3 = table(simulated_wins_r3[,i])[2]
  percent3 = round(wins3/n_sims, digits = 4)*100
  wins4 = table(simulated_champs[,i])[2]
  percent4 = round(wins4/n_sims, digits = 4)*100
  
  percent_results[[team]] <- c(percent1, percent2, percent3, percent4)
}

rownames(percent_results) <- c("Round 1", "Quarterfinal", "Semifinal", "Championship")

team_colors <- c(
  "Oregon" = "#007030",
  "Georgia" = "#BA0C2F",
  "Boise State" = "#0033A0",
  "Arizona State" = "#8C1D40",
  "Texas" = "#bf5700",
  "Penn State" = "#041E42",
  "Notre Dame" = "#c99700",
  "Ohio State" = "#BB0000",
  "Tennessee" = "#FF8200",
  "Indiana" = "#990000",
  "SMU" = "#0033A0",
  "Clemson" = "#F56600"
)

plot_df <- percent_results |>
  as.data.frame() |>
  tibble::rownames_to_column("Round") |>
  tidyr::pivot_longer(-Round, names_to = "Team", values_to = "Percent")

plot_r1 <- plot_df |> dplyr::filter(Round == "Round 1")
plot_r2 <- plot_df |> dplyr::filter(Round == "Quarterfinal")
plot_r3 <- plot_df |> dplyr::filter(Round == "Semifinal")
plot_r4 <- plot_df |> dplyr::filter(Round == "Championship")

plots = c(plot_r1, plot_r2, plot_r3, plot_r4)

round1plot = ggplot2::ggplot(plot_r1, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Round 1 Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round2plot = ggplot2::ggplot(plot_r2, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Quarterfinal Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round3plot = ggplot2::ggplot(plot_r3, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Semifinal Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round4plot = ggplot2::ggplot(plot_r4, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Championship Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

ggsave("round1_2024.png", plot = round1plot, width = 8, height = 5, dpi = 300)
ggsave("round2_2024.png", plot = round2plot, width = 8, height = 5, dpi = 300)
ggsave("round3_2024.png", plot = round3plot, width = 8, height = 5, dpi = 300)
ggsave("round4_2024.png", plot = round4plot, width = 8, height = 5, dpi = 300)

We look at the percentages to advance again:

print(percent_results)
##              Oregon Georgia Boise State Arizona State Texas Penn State
## Round 1       100.0   100.0       100.0         100.0  70.6       61.4
## Quarterfinal   46.4    47.8        33.4          33.2  52.4       42.8
## Semifinal      28.4    28.6        10.2           7.6  24.6       20.6
## Championship   15.6    11.6         1.8           3.2  13.4       10.6
##              Notre Dame Ohio State Tennessee Indiana  SMU Clemson
## Round 1            60.8       65.4      34.6    39.2 38.6    29.4
## Quarterfinal       34.4       39.8      13.8    17.8 23.8    14.4
## Semifinal          21.0       26.0       8.2    10.0  9.6     5.2
## Championship       11.6       17.6       4.2     4.4  4.8     1.2
round1plot

round2plot

round3plot

round4plot

From this we can see that our model performed fairly well on the 2024 playoffs. It got the entire first round correct, as well as predicting Ohio State (#8 seed) to win the national championship which they did. Below again we see how our model did compared to what actually happened in the playoffs:

Figure 2
Figure 2

Toilet Bowl

Finally we did the same thing with the worst 12 teams in college football this year for fun.

fpi <- cfbd_ratings_fpi(year = 2025) |> dplyr::transmute(team, fpi)
sp  <- cfbd_ratings_sp(year = 2025)  |> dplyr::transmute(team, sp = rating) |> dplyr::filter(team != "nationalAverages")
elo <- cfbd_ratings_elo(year = 2025) |> dplyr::transmute(team, elo)
srs <- cfbd_ratings_srs(year = 2025) |> dplyr::transmute(team, srs = rating)

z_scores <- fpi |> dplyr::full_join(sp,  by = "team") |> dplyr::full_join(elo, by = "team") |> dplyr::full_join(srs, by = "team") |> tidyr::drop_na() |>
  mutate(
    z_fpi = as.numeric(scale(fpi)),
    z_sp  = as.numeric(scale(sp)),
    z_elo = as.numeric(scale(elo)),
    z_srs = as.numeric(scale(srs)),
    composite = (z_fpi + z_sp + z_elo + z_srs)/4
  )

teams2 = (z_scores |> dplyr::distinct(team, .keep_all = TRUE) |> dplyr::arrange(composite) |> head(n=12))$team

ratings_com = c()
for (t in teams2) {
  rating = (z_scores |> dplyr::filter(team == t))$composite[1]
  cat(t, ": ", rating, "\n", sep="")
  ratings_com = c(ratings_com, rating)
}
## Massachusetts: -2.407886
## Sam Houston: -1.966597
## Charlotte: -1.84027
## Kent State: -1.717262
## UL Monroe: -1.671364
## Georgia State: -1.592223
## Ball State: -1.586089
## Middle Tennessee: -1.45995
## Akron: -1.398296
## Oklahoma State: -1.371857
## Eastern Michigan: -1.331184
## Nevada: -1.293601
probs_com <- 1/(1 + exp(-1 * outer(X = ratings_com, Y = ratings_com, FUN = "-")))
diag(probs_com) <- NA
round(probs_com, digits = 3)
##        [,1]  [,2]  [,3]  [,4]  [,5]  [,6]  [,7]  [,8]  [,9] [,10] [,11] [,12]
##  [1,]    NA 0.391 0.362 0.334 0.324 0.307 0.305 0.279 0.267 0.262 0.254 0.247
##  [2,] 0.609    NA 0.468 0.438 0.427 0.407 0.406 0.376 0.362 0.356 0.346 0.338
##  [3,] 0.638 0.532    NA 0.469 0.458 0.438 0.437 0.406 0.391 0.385 0.375 0.367
##  [4,] 0.666 0.562 0.531    NA 0.489 0.469 0.467 0.436 0.421 0.414 0.405 0.396
##  [5,] 0.676 0.573 0.542 0.511    NA 0.480 0.479 0.447 0.432 0.426 0.416 0.407
##  [6,] 0.693 0.593 0.562 0.531 0.520    NA 0.498 0.467 0.452 0.445 0.435 0.426
##  [7,] 0.695 0.594 0.563 0.533 0.521 0.502    NA 0.469 0.453 0.447 0.437 0.427
##  [8,] 0.721 0.624 0.594 0.564 0.553 0.533 0.531    NA 0.485 0.478 0.468 0.459
##  [9,] 0.733 0.638 0.609 0.579 0.568 0.548 0.547 0.515    NA 0.493 0.483 0.474
## [10,] 0.738 0.644 0.615 0.586 0.574 0.555 0.553 0.522 0.507    NA 0.490 0.480
## [11,] 0.746 0.654 0.625 0.595 0.584 0.565 0.563 0.532 0.517 0.510    NA 0.491
## [12,] 0.753 0.662 0.633 0.604 0.593 0.574 0.573 0.541 0.526 0.520 0.509    NA
design <- 
  data.frame(home_team = c(5, 6, 7, 8), away_team = c(12, 11, 10, 9)) |>
  dplyr::rowwise() |>
  dplyr::mutate(prob = probs_com[home_team, away_team]) |>
  dplyr::ungroup()

n_sims <- 500
simulated_wins_r1 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r2 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_wins_r3 <- matrix(data = NA, nrow = n_sims, ncol = 12)
simulated_champs <- matrix(data = NA, nrow = n_sims, ncol = 12)
for(r in 1:n_sims){
  set.seed(478+r)
  outcomes <- rbinom(n = 4, size = 1, prob = design$prob)
  round1 <-
    design |>
    dplyr::select(home_team, away_team) |>
    dplyr::mutate(
      outcome = outcomes,
      winner = ifelse(outcome == 1, home_team, away_team),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r1[r,] = round1 |> dplyr::pull(Wins)
  
  winner_512 = ifelse(round1$Wins[5] == 1, 5, 12)
  winner_611 = ifelse(round1$Wins[6] == 1, 6, 11)
  winner_710 = ifelse(round1$Wins[7] == 1, 7, 10)
  winner_89 = ifelse(round1$Wins[8] == 1, 8, 9)
  
  design2 <- 
    data.frame(high_seed = c(1, 2, 3, 4), low_seed = c(winner_89, winner_710, winner_611, winner_512)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[high_seed, low_seed]) |>
    dplyr::ungroup()
  design2
  
  outcomes2 <- rbinom(n = 4, size = 1, prob = design2$prob)
  round2 <-
    design2 |>
    dplyr::select(high_seed, low_seed) |>
    dplyr::mutate(
      outcome = outcomes2,
      winner = ifelse(outcome == 1, high_seed, low_seed),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r2[r,] = round2 |> dplyr::pull(Wins)
  
  winner_4512 = if (round2$Wins[4] == 1) {
    4
  } else if (round2$Wins[5] == 1) {
    5
  } else {
    12
  }
  winner_3611 = if (round2$Wins[3] == 1) {
    3
  } else if (round2$Wins[6] == 1) {
    6
  } else {
    11
  }
  winner_2710 = if (round2$Wins[2] == 1) {
    2
  } else if (round2$Wins[7] == 1) {
    7
  } else {
    10
  }
  winner_189 = if (round2$Wins[1] == 1) {
    1
  } else if (round2$Wins[8] == 1) {
    8
  } else {
    9
  }
  
  design3 <- 
    data.frame(team1 = c(winner_189, winner_2710), team2 = c(winner_4512, winner_3611)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[team1, team2]) |>
    dplyr::ungroup()
  design3
  
  outcomes3 <- rbinom(n = 2, size = 1, prob = design3$prob)
  round3 <-
    design3 |>
    dplyr::select(team1, team2) |>
    dplyr::mutate(
      outcome = outcomes3,
      winner = ifelse(outcome == 1, team1, team2),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_wins_r3[r,] = round3 |> dplyr::pull(Wins)
  
  champ_team1 = (round3 |> dplyr::filter(Wins != 0))$Team[1]
  champ_team2 = (round3 |> dplyr::filter(Wins != 0))$Team[2]
  
  design4 <- 
    data.frame(team1 = c(champ_team1), team2 = c(champ_team2)) |>
    dplyr::rowwise() |>
    dplyr::mutate(prob = probs_com[team1, team2]) |>
    dplyr::ungroup()
  design4
  
  outcomes4 <- rbinom(n = 1, size = 1, prob = design4$prob)
  round4 <-
    design4 |>
    dplyr::select(team1, team2) |>
    dplyr::mutate(
      outcome = outcomes4,
      winner = ifelse(outcome == 1, team1, team2),
      winner = factor(winner, levels = 1:12)) |>
    dplyr::group_by(winner) |>
    dplyr::summarise(Wins = dplyr::n()) |>
    dplyr::rename(Team = winner) |>
    tidyr::complete(Team, fill = list(Wins = 0))
  
  simulated_champs[r,] = round4 |> dplyr::pull(Wins)
}

percent_results <- data.frame(matrix(nrow = 4, ncol = 0))

for (i in 1:length(teams)) {
  team = teams2[i]
  wins1 = table(simulated_wins_r1[,i])[2]
  percent1 = ifelse(i < 5, 100, round(wins1/n_sims, digits = 4)*100)
  wins2 = table(simulated_wins_r2[,i])[2]
  percent2 = round(wins2/n_sims, digits = 4)*100
  wins3 = table(simulated_wins_r3[,i])[2]
  percent3 = round(wins3/n_sims, digits = 4)*100
  wins4 = table(simulated_champs[,i])[2]
  percent4 = round(wins4/n_sims, digits = 4)*100
  
  percent_results[[team]] <- c(percent1, percent2, percent3, percent4)
}

rownames(percent_results) <- c("Round 1", "Quarterfinal", "Semifinal", "Championship")

team_colors <- c(
  "Massachusetts" = "#971B2F",
  "Sam Houston" = "#fe5100",
  "Charlotte" = "#046A38",
  "Kent State" = "#EAAB00",
  "UL Monroe" = "#840029",
  "Georgia State" = "#0039A6",
  "Ball State" = "#BA0C2F",
  "Middle Tennessee" = "#0066CC",
  "Akron" = "#A89968",
  "Oklahoma State" = "#FF7300",
  "Eastern Michigan" = "#006633",
  "Nevada" = "#003366"
)

plot_df <- percent_results |>
  as.data.frame() |>
  tibble::rownames_to_column("Round") |>
  tidyr::pivot_longer(-Round, names_to = "Team", values_to = "Percent")

plot_r1 <- plot_df |> dplyr::filter(Round == "Round 1")
plot_r2 <- plot_df |> dplyr::filter(Round == "Quarterfinal")
plot_r3 <- plot_df |> dplyr::filter(Round == "Semifinal")
plot_r4 <- plot_df |> dplyr::filter(Round == "Championship")

plots = c(plot_r1, plot_r2, plot_r3, plot_r4)

round1plot = ggplot2::ggplot(plot_r1, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Round 1 Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round2plot = ggplot2::ggplot(plot_r2, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Quarterfinal Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round3plot = ggplot2::ggplot(plot_r3, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Semifinal Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

round4plot = ggplot2::ggplot(plot_r4, aes(x = reorder(Team, -Percent), y = Percent, fill = Team)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.1f%%", Percent)),
            vjust = -0.4, size = 3) +
  labs(
    title = "Championship Win Probabilities",
    x = "Team",
    y = "Win Probability (%)"
  ) +
  scale_fill_manual(values = team_colors) +
  theme_minimal(base_size = 14) +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

ggsave("round1_plotbowl.png", plot = round1plot, width = 8, height = 5, dpi = 300)
ggsave("round2_plotbowl.png", plot = round2plot, width = 8, height = 5, dpi = 300)
ggsave("round3_plotbowl.png", plot = round3plot, width = 8, height = 5, dpi = 300)
ggsave("round4_plotbowl.png", plot = round4plot, width = 8, height = 5, dpi = 300)
print(percent_results)
##              Massachusetts Sam Houston Charlotte Kent State UL Monroe
## Round 1              100.0       100.0     100.0      100.0      40.4
## Quarterfinal          26.6        37.8      42.8       44.8      18.4
## Semifinal              9.6        15.0      20.2       20.8       9.6
## Championship           3.6         5.6       7.2       11.8       4.6
##              Georgia State Ball State Middle Tennessee Akron Oklahoma State
## Round 1               42.4       45.2             48.4  51.6           54.8
## Quarterfinal          22.2       28.0             36.4  37.0           34.2
## Semifinal             11.6       14.6             20.2  20.0           18.0
## Championship           6.6        5.8              9.8  12.2           10.4
##              Eastern Michigan Nevada
## Round 1                  57.6   59.6
## Quarterfinal             35.0   36.8
## Semifinal                20.6   19.8
## Championship              9.6   12.8
round1plot

round2plot

round3plot

round4plot

Conclusion

Overall, the simulation behaved about as expected. In 2025, Ohio State and Indiana emerged as clear leaders across advancement stages, while lower seeds like Oregon and Notre Dame gained meaningful probability mass as paths opened up. It makes sense with a 12-team single-elimination bracket where mid-tier teams can string wins together. The 2024 back-test was decent, but the model overvalued Georgia relative to how the bracket actually unfolded (see Figure 2), highlighting how random the CFP can be. Taken together, the results show that top seeds benefit both from underlying strength and bracket structure, while select lower seeds can still move the needle with favorable matchups.